feat: expandable inline diff hunk viewer with toolbar#691
feat: expandable inline diff hunk viewer with toolbar#691
Conversation
Add a compact floating toolbar that appears above expanded diff hunks, providing navigation (prev/next), restore, and dismiss actions. - HunkToolbarView: pill-shaped AppKit overlay with SF Symbol buttons - HunkToolbarAction: enum for toolbar actions with accessibility IDs - InlineDiffProvider: hunk navigation, position info, summary, line range - GutterTextView: click-outside-hunk dismissal, onHunkDismissed callback - Toolbar repositions on scroll, hides on dismiss/data change - 37 unit tests covering navigation, dismissal, edge cases
✅ Code Coverage: 76.2%Threshold: 70% Coverage is above the minimum threshold. Generated by CI — see job summary for detailed file-level breakdown. |
batonogov
left a comment
There was a problem hiding this comment.
Code Review — REQUEST CHANGES
Что сделано хорошо
- Чистая архитектура:
HunkToolbarActionenum,InlineDiffProviderstatic methods,HunkToolbarViewкак отдельный AppKit view - Dismiss из всех источников (Escape, click outside, data change) правильно централизован через
onHunkDismissedcallback - Wrap-around навигация, stale hunk fallback — продуманные edge cases
- 37 тестов покрывают логику навигации, dismiss, summary, line range
- Refactoring
dismissExpandedHunk()— устранило дублирование кода Escape + click outside
CRITICAL — masksToBounds + shadow конфликт
Pine/HunkToolbarView.swift, строки 60-89:
layer?.masksToBounds = true // line 61
// ...
layer?.shadowRadius = 3 // line 88
layer?.shadowOpacity = 1 // line 89masksToBounds = true обрезает shadow. Тень НИКОГДА не будет видна. Это либо мёртвый код (shadow), либо баг (нет тени у toolbar). Нужно убрать masksToBounds = true и вместо этого использовать layer?.cornerCurve = .continuous только для cornerRadius, или сделать два слоя (inner + outer).
CRITICAL — динамические цвета не обновляются при смене appearance
Pine/HunkToolbarView.swift, строки 63-82:
layer?.backgroundColor = bgColor.cgColor и layer?.borderColor = borderColor.cgColor — .cgColor вычисляется ОДИН РАЗ при init. При переключении Dark/Light mode цвета останутся от первоначальной темы. Нужен override viewDidChangeEffectiveAppearance() или updateLayer() для пересчёта layer?.backgroundColor и layer?.borderColor.
Аналогичная проблема у separator в makeSeparator() — layer?.backgroundColor не обновится.
IMPORTANT — O(n) линейный скан в lineNumberAtPoint на каждый mouseDown
Pine/CodeEditorView.swift, lineNumberAtPoint(_:), строки 540-543:
for i in 0..<min(charIndex, source.length) where source.character(at: i) == ASCII.newline {
line += 1
}Для файла в 100K строк это ~100K итераций на каждый клик мыши. В Coordinator уже есть lineStartsCache: LineStartsCache с O(log n) binary search. lineNumberAtPoint должен использовать его, а не сканировать текст заново. Проблема: lineStartsCache живёт в Coordinator, а метод — в GutterTextView. Нужно либо передать cache в GutterTextView, либо вынести логику в Coordinator.
IMPORTANT — дублирование line-to-charOffset кода
Pine/CodeEditorView.swift, строки 1952 и 2037:
Один и тот же паттерн "while loop считает newline" повторяется в positionToolbar и scrollToHunk. Плюс третий раз в lineNumberAtPoint. Нужно вынести в private helper method (или использовать lineStartsCache).
IMPORTANT — toolbar не зануляется при hideHunkToolbar
Pine/CodeEditorView.swift, hideHunkToolbar():
func hideHunkToolbar() {
hunkToolbar?.isHidden = true
hunkToolbar?.removeFromSuperview()
}Toolbar удаляется из view hierarchy, но ссылка hunkToolbar сохраняется. При следующем showHunkToolbar переиспользуется старый экземпляр (if let existing = hunkToolbar). Это не memory leak, но при повторном показе toolbar будет addSubview ЗАНОВО. Это ок как design choice, но стоит добавить комментарий, что переиспользование — intentional. Либо занулить: hunkToolbar = nil в hideHunkToolbar.
IMPORTANT — accessibility IDs не в AccessibilityIdentifiers.swift
Toolbar accessibility IDs ("hunk-toolbar", "hunk-toolbar-summary", "hunk-toolbar-previous" и т.д.) захардкожены в HunkToolbarView и HunkToolbarAction, но не добавлены в Pine/AccessibilityIdentifiers.swift — нарушение проектного соглашения. Все accessibility identifiers должны быть в одном месте.
IMPORTANT — tooltip строки не локализованы
"Previous Change", "Next Change", "Restore", "Dismiss" захардкожены прямо в HunkToolbarView.swift. По conventions проекта строки должны идти в Strings.swift и Localizable.xcstrings.
SUGGESTION — тест hunkToolbarViewActionCallback не тестирует реальное нажатие кнопки
toolbar.onAction?(.restore) // это ручной вызов callback, не simulate button clickЛучше бы через button.performClick(nil). Текущий тест проверяет, что Swift closures работают, а не что кнопки подключены к actions.
SUGGESTION — нет confirmation для Restore action
Restore необратимо откатывает изменения в hunk. Нет NSAlert/confirmation dialog. VS Code показывает confirmation для revert. Стоит хотя бы создать issue если не хотите делать сейчас.
Итого для merge:
- Fix masksToBounds + shadow conflict
- Fix dynamic color update on appearance change
- Использовать lineStartsCache вместо O(n) scan в lineNumberAtPoint
- Вынести дублированный line-to-charOffset код в helper
- Перенести accessibility IDs в AccessibilityIdentifiers.swift
- Локализовать строки в Strings.swift
- Remove masksToBounds that clipped shadow on HunkToolbarView - Add viewDidChangeEffectiveAppearance to update colors on Dark/Light switch - Use LineStartsCache in lineNumberAtPoint for O(log n) lookup - Extract duplicated line-to-charOffset while-loop into shared helper - Move toolbar accessibility IDs to AccessibilityIdentifiers.swift - Extract hardcoded tooltip strings to Strings.swift with localizations
batonogov
left a comment
There was a problem hiding this comment.
Повторное ревью PR #691
Предыдущие замечания — статус
Все 6 замечаний из прошлого ревью исправлены:
- masksToBounds — убран, shadow видна. Добавлен тест
hunkToolbarViewShadowNotClipped. - viewDidChangeEffectiveAppearance — переопределён, вызывает
updateAppearanceColors(). Фоны и бордеры обновляются. - O(log n) lineNumberAtPoint — использует
lineStartsCacheс binary search, fallback на O(n). - Единый charOffsetForLine — один static метод с cache parameter, 1-based нумерация.
- Accessibility IDs — 6 ID в
AccessibilityIdentifiers.swift. - Tooltip strings — 4 строки в
Strings.swift+Localizable.xcstrings(en/ru).
Новые замечания
Important: Separator .cgColor не обновляется при смене темы
HunkToolbarView.makeSeparator() (строка ~164) устанавливает layer?.backgroundColor через .cgColor один раз при создании view:
sep.layer?.backgroundColor = NSColor.separatorColor.withAlphaComponent(0.3).cgColorЭтот .cgColor — снимок текущей темы. При переключении Dark/Light сепараторы останутся в старом цвете. updateAppearanceColors() обновляет фон и бордер основного view, но не трогает сепараторы.
Fix: Сохранить ссылки на separator views и обновлять их layer?.backgroundColor внутри updateAppearanceColors(), аналогично тому, как обновляются borderColor и backgroundColor.
Suggestion: Дублированные MARK-комментарии
В GutterTextView два почти идентичных MARK:
- Строка 515:
// MARK: - Click outside hunk dismisses expanded hunk - Строка 565:
// MARK: - Click outside hunk dismisses expanded inline diff
mouseDown под первым, shouldDismissHunkOnClick / dismissExpandedHunk под вторым, а keyDown (Escape) между ними. Логически это одна секция "Hunk dismiss" — стоит объединить в один MARK и сгруппировать mouseDown, shouldDismissHunkOnClick, dismissExpandedHunk, keyDown рядом.
Store separator view references as class properties and update their background color in updateAppearanceColors() so Dark/Light mode transitions are reflected correctly. Also remove duplicate MARK comment in CodeEditorView.swift and add test for separator color updates.
batonogov
left a comment
There was a problem hiding this comment.
Code Review -- APPROVED (self-PR, cannot approve via API)
Separator views fix -- VERIFIED
Fix is correct: separatorViews stored as private(set), updated in updateAppearanceColors(), called from viewDidChangeEffectiveAppearance(). Test covers the scenario.
Minor issues (non-blocking)
- Double
hideHunkToolbar()calls inhandleHunkToolbarAction: both.dismissand.restorecases triggeronHunkDismissedcallback (via settingexpandedHunkID = nil) which callshideHunkToolbar(), then explicitly callhideHunkToolbar()again. Idempotent so no bug, but redundant. - Header comment mismatch in
HunkToolbarView.swift: says← Prev | ↑ summary (2/5) ↓ | Restore | ✕but actual order isPrev Next | summary | Restore Dismiss.
Positives
- Excellent test coverage (457 lines, edge cases, boundary conditions)
- Clean
charOffsetForLine0-based to 1-based migration - Proper
[weak self]in callbacks - Correct xcstrings formatting
- Full accessibility identifiers
Merge origin/main containing #690 (gutter cleanup) and #657 (Swift 6). Conflict resolution: removed onAcceptHunk/onRevertHunk callbacks (deleted in #690), kept hunkToolbar overlay; replaced dead onRevertHunk call with NotificationCenter-based InlineDiffAction.revert; removed drawHunkActionButtons call from LineNumberGutter.
Summary
HunkToolbarView) above expanded diff hunks with navigation arrows (prev/next hunk), restore button, summary label (e.g. "2/5 +3 -1"), and dismiss buttononHunkDismissedcallback ensures toolbar cleanup from all dismiss sources (Escape, click outside, diff data change)InlineDiffProvidermethods:nextHunk,previousHunk,hunkPositionInfo,hunkSummary,expandedLineRangeCloses #689
Test plan
InlineDiffHunkViewerTestscovering:InlineDiffExpandTests(22 tests) still pass